/**
 * @license Highcharts JS v5.0.6 (2016-12-07)
 *
 * (c) 2009-2016 Torstein Honsi
 *
 * License: www.highcharts.com/license
 */
(function (factory) {
  if (typeof module === 'object' && module.exports) {
    module.exports = factory;
  } else {
    factory(Highcharts);
  }
}(function (Highcharts) {
  (function (H) {
    /**
     * (c) 2009-2016 Torstein Honsi
     *
     * License: www.highcharts.com/license
     */
    /**
     * EXPERIMENTAL Highcharts module to place labels next to a series in a natural position.
     *
     * TODO:
     * - add column support (box collision detection, boxesToAvoid logic)
     * - other series types, area etc.
     * - avoid data labels, when data labels above, show series label below.
     * - add more options (connector, format, formatter)
     *
     * http://jsfiddle.net/highcharts/L2u9rpwr/
     * http://jsfiddle.net/highcharts/y5A37/
     * http://jsfiddle.net/highcharts/264Nm/
     * http://jsfiddle.net/highcharts/y5A37/
     */

    'use strict';

    var labelDistance = 3,
      wrap = H.wrap,
      each = H.each,
      extend = H.extend,
      isNumber = H.isNumber,
      Series = H.Series,
      SVGRenderer = H.SVGRenderer,
      Chart = H.Chart;

    H.setOptions({
      plotOptions: {
        series: {
          label: {
            enabled: true,
            // Allow labels to be placed distant to the graph if necessary, and
            // draw a connector line to the graph
            connectorAllowed: true,
            connectorNeighbourDistance: 24, // If the label is closer than this to a neighbour graph, draw a connector
            styles: {
              fontWeight: 'bold'
            }
            // boxesToAvoid: []
          }
        }
      }
    });

    /**
     * Counter-clockwise, part of the fast line intersection logic
     */
    function ccw(x1, y1, x2, y2, x3, y3) {
      var cw = ((y3 - y1) * (x2 - x1)) - ((y2 - y1) * (x3 - x1));
      return cw > 0 ? true : cw < 0 ? false : true;
    }

    /**
     * Detect if two lines intersect
     */
    function intersectLine(x1, y1, x2, y2, x3, y3, x4, y4) {
      return ccw(x1, y1, x3, y3, x4, y4) !== ccw(x2, y2, x3, y3, x4, y4) &&
        ccw(x1, y1, x2, y2, x3, y3) !== ccw(x1, y1, x2, y2, x4, y4);
    }

    /**
     * Detect if a box intersects with a line
     */
    function boxIntersectLine(x, y, w, h, x1, y1, x2, y2) {
      return (
        intersectLine(x, y, x + w, y, x1, y1, x2, y2) || // top of label
        intersectLine(x + w, y, x + w, y + h, x1, y1, x2, y2) || // right of label
        intersectLine(x, y + h, x + w, y + h, x1, y1, x2, y2) || // bottom of label
        intersectLine(x, y, x, y + h, x1, y1, x2, y2) // left of label
      );
    }

    /**
     * General symbol definition for labels with connector
     */
    SVGRenderer.prototype.symbols.connector = function (x, y, w, h, options) {
      var anchorX = options && options.anchorX,
        anchorY = options && options.anchorY,
        path,
        yOffset,
        lateral = w / 2;

      if (isNumber(anchorX) && isNumber(anchorY)) {

        path = ['M', anchorX, anchorY];

        // Prefer 45 deg connectors
        yOffset = y - anchorY;
        if (yOffset < 0) {
          yOffset = -h - yOffset;
        }
        if (yOffset < w) {
          lateral = anchorX < x + (w / 2) ? yOffset : w - yOffset;
        }

        // Anchor below label
        if (anchorY > y + h) {
          path.push('L', x + lateral, y + h);

          // Anchor above label
        } else if (anchorY < y) {
          path.push('L', x + lateral, y);

          // Anchor left of label
        } else if (anchorX < x) {
          path.push('L', x, y + h / 2);

          // Anchor right of label
        } else if (anchorX > x + w) {
          path.push('L', x + w, y + h / 2);
        }
      }
      return path || [];
    };

    /**
     * Points to avoid. In addition to actual data points, the label should avoid
     * interpolated positions.
     */
    Series.prototype.getPointsOnGraph = function () {
      var distance = 16,
        points = this.points,
        point,
        last,
        interpolated = [],
        i,
        deltaX,
        deltaY,
        delta,
        len,
        n,
        j,
        d,
        graph = this.graph || this.area,
        node = graph.element,
        inverted = this.chart.inverted,
        paneLeft = inverted ? this.yAxis.pos : this.xAxis.pos,
        paneTop = inverted ? this.xAxis.pos : this.yAxis.pos;

      // For splines, get the point at length (possible caveat: peaks are not correctly detected)
      if (this.getPointSpline && node.getPointAtLength) {
        // If it is animating towards a path definition, use that briefly, and reset
        if (graph.toD) {
          d = graph.attr('d');
          graph.attr({
            d: graph.toD
          });
        }
        len = node.getTotalLength();
        for (i = 0; i < len; i += distance) {
          point = node.getPointAtLength(i);
          interpolated.push({
            chartX: paneLeft + point.x,
            chartY: paneTop + point.y,
            plotX: point.x,
            plotY: point.y
          });
        }
        if (d) {
          graph.attr({
            d: d
          });
        }
        // Last point
        point = points[points.length - 1];
        point.chartX = paneLeft + point.plotX;
        point.chartY = paneTop + point.plotY;
        interpolated.push(point);

        // Interpolate
      } else {
        len = points.length;
        for (i = 0; i < len; i += 1) {

          point = points[i];
          last = points[i - 1];

          // Absolute coordinates so we can compare different panes
          point.chartX = paneLeft + point.plotX;
          point.chartY = paneTop + point.plotY;

          // Add interpolated points
          if (i > 0) {
            deltaX = Math.abs(point.chartX - last.chartX);
            deltaY = Math.abs(point.chartY - last.chartY);
            delta = Math.max(deltaX, deltaY);
            if (delta > distance) {

              n = Math.ceil(delta / distance);

              for (j = 1; j < n; j += 1) {
                interpolated.push({
                  chartX: last.chartX + (point.chartX - last.chartX) * (j / n),
                  chartY: last.chartY + (point.chartY - last.chartY) * (j / n),
                  plotX: last.plotX + (point.plotX - last.plotX) * (j / n),
                  plotY: last.plotY + (point.plotY - last.plotY) * (j / n)
                });
              }
            }
          }

          // Add the real point in order to find positive and negative peaks
          if (isNumber(point.plotY)) {
            interpolated.push(point);
          }
        }
      }
      return interpolated;
    };

    /**
     * Check whether a proposed label position is clear of other elements
     */
    Series.prototype.checkClearPoint = function (x, y, bBox, checkDistance) {
      var distToOthersSquared = Number.MAX_VALUE, // distance to other graphs
        distToPointSquared = Number.MAX_VALUE,
        dist,
        connectorPoint,
        connectorEnabled = this.options.label.connectorAllowed,

        chart = this.chart,
        series,
        points,
        leastDistance = 16,
        withinRange,
        i,
        j;

      function intersectRect(r1, r2) {
        return !(r2.left > r1.right ||
        r2.right < r1.left ||
        r2.top > r1.bottom ||
        r2.bottom < r1.top);
      }

      /**
       * Get the weight in order to determine the ideal position. Larger distance to
       * other series gives more weight. Smaller distance to the actual point (connector points only)
       * gives more weight.
       */
      function getWeight(distToOthersSquared, distToPointSquared) {
        return distToOthersSquared - distToPointSquared;
      }

      // First check for collision with existing labels
      for (i = 0; i < chart.boxesToAvoid.length; i += 1) {
        if (intersectRect(chart.boxesToAvoid[i], {
            left: x,
            right: x + bBox.width,
            top: y,
            bottom: y + bBox.height
          })) {
          return false;
        }
      }

      // For each position, check if the lines around the label intersect with any of the
      // graphs
      for (i = 0; i < chart.series.length; i += 1) {
        series = chart.series[i];
        points = series.interpolatedPoints;
        if (series.visible && points) {
          for (j = 1; j < points.length; j += 1) {
            // If any of the box sides intersect with the line, return
            if (boxIntersectLine(
                x,
                y,
                bBox.width,
                bBox.height,
                points[j - 1].chartX,
                points[j - 1].chartY,
                points[j].chartX,
                points[j].chartY
              )) {
              return false;
            }

            // But if it is too far away (a padded box doesn't intersect), also return
            if (this === series && !withinRange && checkDistance) {
              withinRange = boxIntersectLine(
                x - leastDistance,
                y - leastDistance,
                bBox.width + 2 * leastDistance,
                bBox.height + 2 * leastDistance,
                points[j - 1].chartX,
                points[j - 1].chartY,
                points[j].chartX,
                points[j].chartY
              );
            }

            // Find the squared distance from the center of the label
            if (this !== series) {
              distToOthersSquared = Math.min(
                distToOthersSquared,
                Math.pow(x + bBox.width / 2 - points[j].chartX, 2) + Math.pow(y + bBox.height / 2 - points[j].chartY, 2),
                Math.pow(x - points[j].chartX, 2) + Math.pow(y - points[j].chartY, 2),
                Math.pow(x + bBox.width - points[j].chartX, 2) + Math.pow(y - points[j].chartY, 2),
                Math.pow(x + bBox.width - points[j].chartX, 2) + Math.pow(y + bBox.height - points[j].chartY, 2),
                Math.pow(x - points[j].chartX, 2) + Math.pow(y + bBox.height - points[j].chartY, 2)
              );
            }
          }

          // Do we need a connector?
          if (connectorEnabled && this === series && ((checkDistance && !withinRange) ||
            distToOthersSquared < Math.pow(this.options.label.connectorNeighbourDistance, 2))) {
            for (j = 1; j < points.length; j += 1) {
              dist = Math.min(
                Math.pow(x + bBox.width / 2 - points[j].chartX, 2) + Math.pow(y + bBox.height / 2 - points[j].chartY, 2),
                Math.pow(x - points[j].chartX, 2) + Math.pow(y - points[j].chartY, 2),
                Math.pow(x + bBox.width - points[j].chartX, 2) + Math.pow(y - points[j].chartY, 2),
                Math.pow(x + bBox.width - points[j].chartX, 2) + Math.pow(y + bBox.height - points[j].chartY, 2),
                Math.pow(x - points[j].chartX, 2) + Math.pow(y + bBox.height - points[j].chartY, 2)
              );
              if (dist < distToPointSquared) {
                distToPointSquared = dist;
                connectorPoint = points[j];
              }
            }
            withinRange = true;
          }
        }
      }

      return !checkDistance || withinRange ? {
        x: x,
        y: y,
        weight: getWeight(distToOthersSquared, connectorPoint ? distToPointSquared : 0),
        connectorPoint: connectorPoint
      } : false;

    };

    /**
     * The main initiator method that runs on chart level after initiation and redraw. It runs in
     * a timeout to prevent locking, and loops over all series, taking all series and labels into
     * account when placing the labels.
     */
    Chart.prototype.drawSeriesLabels = function () {
      var chart = this,
        labelSeries = this.labelSeries;

      chart.boxesToAvoid = [];

      // Build the interpolated points
      each(labelSeries, function (series) {
        series.interpolatedPoints = series.getPointsOnGraph();

        each(series.options.label.boxesToAvoid || [], function (box) {
          chart.boxesToAvoid.push(box);
        });
      });

      each(chart.series, function (series) {
        var bBox,
          x,
          y,
          results = [],
          clearPoint,
          i,
          best,
          inverted = chart.inverted,
          paneLeft = inverted ? series.yAxis.pos : series.xAxis.pos,
          paneTop = inverted ? series.xAxis.pos : series.yAxis.pos,
          paneWidth = chart.inverted ? series.yAxis.len : series.xAxis.len,
          paneHeight = chart.inverted ? series.xAxis.len : series.yAxis.len,
          points = series.interpolatedPoints,
          label = series.labelBySeries;

        function insidePane(x, y, bBox) {
          return x > paneLeft && x <= paneLeft + paneWidth - bBox.width &&
            y >= paneTop && y <= paneTop + paneHeight - bBox.height;
        }

        if (series.visible && points) {
          if (!label) {
            series.labelBySeries = label = chart.renderer
              .label(series.name, 0, -9999, 'connector')
              .css(extend({
                color: series.color
              }, series.options.label.styles))
              .attr({
                padding: 0,
                opacity: 0,
                stroke: series.color,
                'stroke-width': 1
              })
              .add(series.group)
              .animate({
                opacity: 1
              }, {
                duration: 200
              });
          }

          bBox = label.getBBox();
          bBox.width = Math.round(bBox.width);

          // Ideal positions are centered above or below a point on right side
          // of chart
          for (i = points.length - 1; i > 0; i -= 1) {

            // Right - up
            x = points[i].chartX + labelDistance;
            y = points[i].chartY - bBox.height - labelDistance;
            if (insidePane(x, y, bBox)) {
              best = series.checkClearPoint(
                x,
                y,
                bBox
              );
            }
            if (best) {
              results.push(best);
            }

            // Right - down
            x = points[i].chartX + labelDistance;
            y = points[i].chartY + labelDistance;
            if (insidePane(x, y, bBox)) {
              best = series.checkClearPoint(
                x,
                y,
                bBox
              );
            }
            if (best) {
              results.push(best);
            }

            // Left - down
            x = points[i].chartX - bBox.width - labelDistance;
            y = points[i].chartY + labelDistance;
            if (insidePane(x, y, bBox)) {
              best = series.checkClearPoint(
                x,
                y,
                bBox
              );
            }
            if (best) {
              results.push(best);
            }

            // Left - up
            x = points[i].chartX - bBox.width - labelDistance;
            y = points[i].chartY - bBox.height - labelDistance;
            if (insidePane(x, y, bBox)) {
              best = series.checkClearPoint(
                x,
                y,
                bBox
              );
            }
            if (best) {
              results.push(best);
            }

          }

          // Brute force, try all positions on the chart in a 16x16 grid
          if (!results.length) {
            for (x = paneLeft + paneWidth - bBox.width; x >= paneLeft; x -= 16) {
              for (y = paneTop; y < paneTop + paneHeight - bBox.height; y += 16) {
                clearPoint = series.checkClearPoint(x, y, bBox, true);
                if (clearPoint) {
                  results.push(clearPoint);
                }
              }
            }
          }

          if (results.length) {

            results.sort(function (a, b) {
              return b.weight - a.weight;
            });

            best = results[0];

            chart.boxesToAvoid.push({
              left: best.x,
              right: best.x + bBox.width,
              top: best.y,
              bottom: best.y + bBox.height
            });

            // Move it if needed
            if (Math.round(best.x) !== Math.round(label.x) ||
              Math.round(best.y) !== Math.round(label.y)) {
              series.labelBySeries
                .attr({
                  opacity: 0,
                  x: best.x - paneLeft,
                  y: best.y - paneTop,
                  anchorX: best.connectorPoint && best.connectorPoint.plotX,
                  anchorY: best.connectorPoint && best.connectorPoint.plotY
                })
                .animate({
                  opacity: 1
                });

              // Record closest point to stick to for sync redraw
              series.options.kdNow = true;
              series.buildKDTree();
              var closest = series.searchPoint({
                chartX: best.x,
                chartY: best.y
              }, true);
              label.closest = [
                closest,
                best.x - paneLeft - closest.plotX,
                best.y - paneTop - closest.plotY
              ];

            }

          } else if (label) {
            series.labelBySeries = label.destroy();
          }
        }
      });
    };

    /**
     * Prepare drawing series labels
     */
    function drawLabels(proceed) {

      var chart = this,
        delay = Math.max(
          H.animObject(chart.renderer.globalAnimation).duration,
          250
        ),
        initial = !chart.hasRendered;

      proceed.apply(chart, [].slice.call(arguments, 1));

      chart.labelSeries = [];

      clearTimeout(chart.seriesLabelTimer);

      // Which series should have labels
      each(chart.series, function (series) {
        var options = series.options.label,
          label = series.labelBySeries,
          closest = label && label.closest;

        if (options.enabled && series.visible && (series.graph || series.area)) {
          chart.labelSeries.push(series);

          // The labels are processing heavy, wait until the animation is done
          if (initial) {
            delay = Math.max(
              delay,
              H.animObject(series.options.animation).duration
            );
          }

          // Keep the position updated to the axis while redrawing
          if (closest) {
            if (closest[0].plotX !== undefined) {
              label.animate({
                x: closest[0].plotX + closest[1],
                y: closest[0].plotY + closest[2]
              });
            } else {
              label.attr({
                opacity: 0
              });
            }
          }
        }
      });

      chart.seriesLabelTimer = setTimeout(function () {
        chart.drawSeriesLabels();
      }, delay);

    }

    wrap(Chart.prototype, 'render', drawLabels);
    wrap(Chart.prototype, 'redraw', drawLabels);

  }(Highcharts));
}));
